【RAG】Amazon BedrockとConnect、Kendraを利用し、社内情報や社外の最新情報などの取り込んだデータをもとに回答するコールセンター向けAIチャットボットを構築してみた
はじめに
Amazon BedrockとAmazon Connect、Amazon Kendraを利用し、電話での質問に対して、取り込んだ情報をもとに検索し、回答する(Retrieval Augmented Generation(以降、RAG))コールセンター向けAIチャットボットを構築してみました。
以前、Connectをインターフェースとして、BedrockのClaude V2に質問するチャットボットを構築しましたが、今回はKendraを採用したRAG版です。
最近、社内の業務効率化などの目的で、AIの言語モデル(以降、LLM)を用いて社内情報を活用するための手法として、RAGが話題になっています。
RAGとは、ユーザーからの問い合わせ(プロンプト)に基づいて外部データから関連するドキュメントを検索し、その結果をもとにLLMが質問への回答を生成するという手法です。
RAGの検索(Retrieval)の部分は、Amazon Kendraという様々なデータソースから特定の情報を迅速に見つけ出すことができるエンタープライズ向けの検索サービスで実現できます。
また、先日リリースした生成AIプラットフォームのAmazon BedrockのAPIを介してLLMが利用できます。
つまり、事前にKendraに社内情報をインポートし、ユーザーからの問い合わせに対して、Kendraで検索し、その結果をもとにLLMが回答を生成することでRAGを実現できます。
ユーザーからの問い合わせを行うインターフェースとして、クラウド型コンタクトセンターのAmazon Connectと、チャットボット等の会話インターフェースを簡易に利用できるAmazon Lexを採用します。
構成
今回の構成の図は、以下になります
Kendra
今回の検証において、Kendraのインデックスには、Network Load Balancer(NLB)のAWSドキュメントをウェブクローラーでインポートします。
Kendraのデータソース(取り込み元)としては、ウェブサイトやS3バケットに保存したドキュメントなどが利用できます。
実際には、社内情報のドキュメントをS3バケットなどに保存して、Kendraにインポートさせると思いますが、今回は、検証が簡単なウェブクローラーを利用します。
Lambda
Lambdaで行っていることは、次の3つです
- Lexで文字起こしされた文章をBedrockのClaude V2に整形してもらいます
- 整形された文章をもとに、Kendraでの検索結果で関連性の高い上位7つの参考ドキュメントを取得
- BedrockのClaude V2に対して、2.のドキュメントと質問内容をもとに、回答を生成させ、Lexに渡します。
デモ動画
2023年の8月に、NLBにセキュリティグループを割り当てることができるアップデートがありました。
Bedrockの日本語対応のClaude V2に聞いた場合、最新の情報は持っていないため、NLBはセキュリティグループを適用できないと答えます。
そのため、Kendraに最新のアップデート情報を含むAWSドキュメントをインポートすることで、アップデート情報にも的確に回答することができるはずです。
以下は、電話をかけた際の対話の様子を示したイメージです:
以下が実際のデモ動画です。
チャットボットの回答内容は、アップデート日や注意点が的確で、日本語の違和感がなく回答としては全く問題ないですね。
参考情報から、ネットワークロードバランサーがセキュリティグループを関連付けることができるようになったのは2023年8月10日からです。 作成時にセキュリティグループをネットワークロードバランサーに関連付けることができます。 関連付けない場合は、後から追加することはできません。 セキュリティグループの設定はいつでも変更できますが、作成後に初めて関連付けることはできません。 ターゲットのセキュリティグループも、ロードバランサーからのトラフィックを受け入れるよう設定する必要があります。
ただし、質問してから回答までのレスポンス時間は30秒と長いです。改善点は、例えば以下が考えられます。
- LambdaでClaude V2を2回利用しているので、質問を整形する処理は、Claude V2よりもレスポンス時間が短いClaude Instantを採用する
- 後述しますが、Claude Instantの場合、プロンプトの工夫が必要です
構築
以下の流れで構築します。
- Bedrockの有効化
- Kendraのインデックスを作成
- Lambda関数の作成
- Lexを構築
- Connectのコンタクトフローを作成
Claude V2
はバージニアリージョンのみで利用できるため、リソースは、全てバージニアリージョンで作成します。
Bedrockの有効化
AnthropicのClaude
とClaude Instant
を利用可能な状態にしておきます。
Kendraのインデックスを作成
下記の記事通りにKendraのインデックスを作成します。
参考記事と変える点について、データソースは、AWSのNLBのドキュメントURLにします。
https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/network/introduction.html
Lambda関数を作成
ランタイムがPython3.11のLambda関数を作成します。以下を参考に、IAMロールやBedrockを利用するためのBoto3ライブラリをアップロードしてください。
上記に加え、以下の設定をしました。
- IAMロールには、以下の2つを割り当てます
CloudWatchLogsFullAccess
AmazonKendraFullAccess
- 実行時間は、デフォルトの3秒から1分に変更しました。
コードは下記の通りです。
import json import boto3 from decimal import Decimal bedrock_runtime_client = boto3.client('bedrock-runtime') def decimal_to_int(obj): if isinstance(obj, Decimal): return int(obj) def elicit_slot(slot_to_elicit, intent_name, slots): return { 'sessionState': { 'dialogAction': { 'type': 'ElicitSlot', 'slotToElicit': slot_to_elicit, }, 'intent': { 'name': intent_name, 'slots': slots, 'state': 'InProgress' } } } def confirm_intent(message_content, intent_name, slots): return { 'messages': [{'contentType': 'PlainText', 'content': message_content}], 'sessionState': { 'dialogAction': { 'type': 'ConfirmIntent', }, 'intent': { 'name': intent_name, 'slots': slots, 'state': 'Fulfilled' } } } def close(fulfillment_state, message_content, intent_name, slots): return { 'messages': [{'contentType': 'PlainText', 'content': message_content}], "sessionState": { 'dialogAction': { 'type': 'Close', }, 'intent': { 'name': intent_name, 'slots': slots, 'state': fulfillment_state } } } def get_retrieval_result(query_text,index_id): kendra_client = boto3.client('kendra') response = kendra_client.query( QueryText=query_text, IndexId=index_id, AttributeFilter={ "EqualsTo": { "Key": "_language_code", "Value": {"StringValue": "ja"}, }, }, ) # Kendra の応答から最初の7つの結果を抽出 results = response['ResultItems'][:7] if response['ResultItems'] else [] for i in range(len(results)): results[i] = results[i].get("DocumentExcerpt", {}).get("Text", "").replace('\\n', ' ') return json.dumps(results, ensure_ascii=False) def get_arrange_question(user_prompt): prompt = f"""\n\nHuman: 以下の文章に句読点をいれて整えて下さい。 「{user_prompt}」 Assistant: """ # modelId = 'anthropic.claude-instant-v1' modelId = 'anthropic.claude-v2' accept = 'application/json' contentType = 'application/json' body = json.dumps({ "prompt": prompt, "max_tokens_to_sample": 400, }) response = bedrock_runtime_client.invoke_model( modelId=modelId, accept=accept, contentType=contentType, body=body ) response_body = json.loads(response.get('body').read()) # print("Received response_body:" + json.dumps(response_body, ensure_ascii=False)) return response_body.get('completion') def get_bedrock_response(user_prompt): # Kendra インデックス ID に置き換えてください index_id = '<index ID>' prompt = f"""\n\nHuman: [参考]情報をもとに[質問]に適切に答えてください。200字以内で答えてください。答える際、200字以内と言わなくてよいです。 [質問] {user_prompt} [参考] {get_retrieval_result(user_prompt,index_id)} Assistant: """ modelId = 'anthropic.claude-v2' accept = 'application/json' contentType = 'application/json' body = json.dumps({ "prompt": prompt, "max_tokens_to_sample": 400, }) response = bedrock_runtime_client.invoke_model( modelId=modelId, accept=accept, contentType=contentType, body=body ) response_body = json.loads(response.get('body').read()) # print("Received response_body:" + json.dumps(response_body, ensure_ascii=False)) return response_body.get('completion') def Bedrock_intent(event): print("Received event:" + json.dumps(event, default=decimal_to_int, ensure_ascii=False)) intent_name = event['sessionState']['intent']['name'] slots = event['sessionState']['intent']['slots'] user_prompt = event['inputTranscript'] if slots['freeinput'] is None: return elicit_slot('freeinput', intent_name, slots) confirmation_status = event['sessionState']['intent']['confirmationState'] if confirmation_status == "Confirmed": return close("Fulfilled", 'それでは、電話を切ります', intent_name, slots) elif confirmation_status == "Denied": return close("Failed", 'お力になれず、申し訳ありません。電話を切ります', intent_name, slots) # confirmation_status == "None" response_text = get_bedrock_response(get_arrange_question(user_prompt)) print("Received response_text:" + json.dumps(response_text, ensure_ascii=False)) return confirm_intent( f'それでは、回答します。{response_text}。以上が回答になります。回答に納得したかたは、はい、とお伝え下さい。納得いかない場合、いいえ、とお伝え下さい', intent_name, slots) def lambda_handler(event, context): print("Received event:" + json.dumps(event, default=decimal_to_int, ensure_ascii=False)) intent_name = event['sessionState']['intent']['name'] if intent_name == 'bedrock': return Bedrock_intent(event)
- 関数名:
elicit_slot
- Lexのスロットの値が埋まっていない場合に使用します。
- 関数名:
confirm_intent
- Lexのスロットが全て埋まった時に使用します。
- 確認プロンプトに設定しているプロンプトを伝えます。今回の場合、「それでは、回答します。.......」になります
- Lexのスロットが全て埋まった時に使用します。
- 関数名:
close
- 確認プロンプトの後、インテントを終了するときに使用します。
- クローズ時に設定しているプロンプトを聞きます。
- 確認プロンプトの後、インテントを終了するときに使用します。
- 関数名:
get_arrange_question
- Lexで文字起こしされた文章をBedrockのClaude V2に整形させます
- 関数名:
get_bedrock_response
- 整形された文章とKendraの検索結果ともとに回答を生成します
if slots['freeinput'] is None:
このうち、freeinput
は、Lexの章で説明するLexのスロット名です。if intent_name == 'bedrock':
このうち、bedrock
は、Lexの章で説明するLexのインテント名です。- KendraのインデックスIDは、Kendraのコンソール画面から確認できます。
- あくまでも検証なので、ハードコーディングしています。
Lambdaコードはあくまでも参考例とご認識下さい。実際の環境で使用する際には、「API呼び出しのレスポンスチェック」や「適切なエラーハンドリング」、「機密情報はハードコーディングしない」などを行う必要があります。
文字起こした内容を整形する必要性
Lambdaでは、Kendraで検索する前に、Lexで文字起こした内容をBedrock Claude V2で整形させています。
整形させる前のLexで文字起こした文章は、下記の通りです。カタカナや漢字に変換されていたりしていますが、無意味なスペースが多く、句読点はありません。
い つ から ネットワーク ロード バランサー は セキュリティ グループ を 適用できる よう に なり まし た か 注意 点 も教え て ください
Bedrock のClaude V2で整形は、下記の通りです。無意味なスペースがなくなり、句読点もつけてくれています。
いつからネットワークロードバランサーはセキュリティグループを適用できるようになりましたか。注意点も教えてください。
整形せず、Kendraで検索すると、今回の質問の回答に該当する「NLBのアップデート情報」のドキュメントは、関連性の上位20番目くらいでした。
ただし、整形後にKendraで検索すると、関連性の上位6番目くらいに出ました。
20番目までのドキュメントを取得すると、回答時の正確性に影響が出たため、整形後にKendraで検索するようにしています。
Lexでの文字起こしの精度がより上がれば(正確にはAmazon Transcribe)、文章の繋ぎもうまく補正し、整形する必要性はなくなりそうです。
Claude Instantの採用検討
Bedrock のClaude V2よりもClaude Instantの方がレスポンス時間は短いので、文字起こしの整形処理は、Claude Instantにしてもらいたいです。
ただし、Claude Instantの場合、たまに、整形しつつ、整形した内容に回答する動作が行われました。
プロンプトを工夫することで、整形のみを行うように改善はできそうですが、今回は文字の整形もClaude V2を採用しました。
質問してから回答までのレスポンス時間の比較表です。整形処理は、Claude Instantにすることで、レスポンス時間の改善に繋がります。
生成AIの利用回数 | レスポンス時間 |
---|---|
Claude V2を2回利用 | おおよそ30秒 |
Claude V2を1回 Claude Instantを1回利用 |
おおよそ20秒 |
上記のレスポンス時間は、Lexの文字起こしの処理時間やKendraでの検索時間も含まれています。
Lexを構築
Amazon Lexのボットとインテントを作成します。
もちろん、対応言語は日本語です。
先程作成したLambdaをLexから呼び出すため、Lambdaを指定し保存します。
Lambdaを呼び出す設定は、分かりにくいのですが、[エイリアス]→対象のエイリアス(TestBotAlias)→[言語:Japanese (Japan)]を順にクリックすると、呼び出す設定画面がでます。
インテント名はbedrock
とし、インテントを呼び出すためのサンプル発話は、はい
にします。
スロット設定は以下です
- スロット名は
freeinput
- スロットタイプは、自由形式の入力を受け付ける
AMAZON.FreeFormInput
- プロンプトは、「ご質問ください」
AMAZON.FreeFormInput
の詳細は、下記をご参照ください。
先程設定したLambdaが利用されるように、初期化と検証に Lambda 関数を使用
にチェックを入れましょう。
この設定で、[インテントを保存]の後、[Build]し、正常に構築されることを確認します。
Connectのコンタクトフローを作成
対象のConnectインスタンスに、先程作成したLexを登録します
フローは、シンプルですが、下記の4つのブロックのみにしました。
音声設定は、Kazuhaにしてます。言語属性を設定
のチェックも忘れずにしましょう。
[顧客の入力を取得する]ブロックでは、読み上げるテキストを入力し、Lexのボット名やエイリアス名、インテント名を記載して保存します。
フロー公開後、電話番号をフローに割り当てば、Connectの設定は、完了です。
電話をかけると、デモ動画の通り、AWSドキュメントをもとに回答をしてくれます。
最後に
レスポンス時間が長いなどの改善点があります。ClaudeV2 を2回リクエストしているので、プロンプトを調整した上でClaude Instantに代替することで、レスポンス時間を改善していきたいです。
また、今回の構成以外にもOpenAIの文字起こしのWhisperやFunction callingなども試して、レスポンス時間や精度の向上を検証します。